MBL
Go / avito / Тестовое задание / Защита тестового Avito на реальном проекте: Avito.Кухня
Go сложный

Защита тестового Avito на реальном проекте: Avito.Кухня

avitointerviewarchitecturekafkapostgrestesting

Начиная с этой страницы — мой собственный проект, не чужой рассказ. В папке «Аскар — реальный собес» разобран опыт знакомого, проходившего собеседование в Avito весной 2026 — три живые задачи и общая структура интервью с его слов. Здесь — другое: я сам строил такой же тестовый проект («Avito.Кухня», сервис заказов в заведении + сервис-пример заведения, Go + Postgres + Kafka), и ниже разбираю СВОЙ реальный код.

Вопросы ниже сформулированы по тем же темам, которые всплывали на собесе у Аскара (разбор тестового — самая долгая часть такого интервью) — но каждый ответ дальше опирается на конкретные решения МОЕГО проекта, включая то, что я сам сначала сделал не так и почему переделал.

Почему разбор тестового — не формальность

На собесе интервьюер тратит на разбор тестового больше времени, чем на все три живые задачи вместе. Это не проверка, работает ли код — работоспособность видна из самого факта, что тестовое прошло автоматическую проверку. Проверяется другое: можешь ли ты аргументировать решение, которое сам же принял, под давлением встречных вопросов "а почему не иначе". Кандидат, не прошедший этот же собес весной, по словам интервьюера, не защитил именно это — не сама реализация подвела, а неспособность уверенно объяснить свой выбор и заранее назвать собственный непокрытый край.

Практический вывод: если ты писал тестовое сам, у тебя должен быть готов не просто ответ "как", а ответ "какие были альтернативы, и почему не они" — на каждое нетривиальное решение. Ниже — то, как я сам вёл журнал решений по ходу разработки (DECISIONS.md: вопрос → варианты → плюсы/минусы каждого → выбор → почему), и как это выглядит в разборе на конкретных примерах.

"Расскажи про структуру проекта, почему так разделил"

Ожидаемый ответ — не "потому что так принято", а конкретная причинно-следственная цепочка от требований к структуре.

Слоистая архитектура handler → usecase → repository, плюс отдельный domain, от которого не зависит никто:

domain      - модели и enum'ы, ни от чего не зависит
repository  - только SQL, не знает про HTTP
usecase     - бизнес-правила, объявляет интерфейсы repository (!)
handler     - парсинг запроса, вызов usecase, маппинг ошибки в HTTP-статус

Ключевая деталь, которую стоит проговорить явно, а не молчать: интерфейсы репозиториев объявлены в пакете usecase, а не в repository.

// usecase/ports.go — потребитель диктует контракт
type OrderRepository interface {
	Create(ctx context.Context, order domain.Order) (domain.Order, error)
	GetForUpdate(ctx context.Context, id uuid.UUID) (domain.Order, error)
	UpdateStatus(ctx context.Context, id uuid.UUID, status domain.OrderStatus) error
	// ...
}

Это не косметика — прямое следствие принципа "consumer dictates the contract" (Go-идиома "accept interfaces, return structs" в применении к слоям): usecase формулирует, что ему нужно от хранилища, а repository реализует ровно это, а не наоборот.

Проверяемое следствие — usecase-пакет тестируется юнит-тестами с фейковыми реализациями этих интерфейсов вообще без импорта repository или pgx. Если бы интерфейс жил в repository, usecase был бы вынужден импортировать пакет с SQL-деталями просто чтобы взять тип — зависимость шла бы не в ту сторону.

Зачем тебе интерфейсы вообще, если реализация всё равно одна (Postgres)? Честный ответ на этот конкретный вопрос с собеса: не ради гипотетической смены БД (это вторичный бонус), а ради тестируемости бизнес-логики без поднятия реальной БД в каждом unit-тесте, и ради явной, документируемой границы слоя — интерфейс в ports.go это буквально контракт, который можно прочитать за 10 секунд и понять, что вообще умеет делать хранилище с точки зрения бизнес-логики.

"Что будет, если тут вернётся ошибка, можно ли улучшить" — маппинг ошибок

В проекте одна точка перевода доменных ошибок в HTTP-статусы на весь сервис — mapError в handler/response.go:

func mapError(err error) (status int, code string) {
	var stockErr domain.InsufficientStockError
	switch {
	case errors.As(err, &stockErr):
		return http.StatusConflict, "insufficient_stock"
	case errors.Is(err, domain.ErrNotFound):
		return http.StatusNotFound, "not_found"
	case errors.Is(err, domain.ErrInvalidFulfillmentType):
		return http.StatusBadRequest, "invalid_fulfillment_type"
	// ...
	default:
		return http.StatusInternalServerError, "internal_error"
	}
}

Это конкретно то место, где я сам ошибся и поймал ошибку только на отдельном ревью, а не сразу. CreateOrder при невалидном fulfillment_type изначально возвращал domain.ErrInvalidTransition, который маппился в 409 invalid_transition — технически "ошибка", но неправильный код и вводящее в заблуждение сообщение (переход тут вообще ни при чём, это ошибка валидации входа).

Правильный ответ на "что будет, если здесь ошибка" — не "вернётся ошибка", а "вернётся не тот HTTP-статус, и клиент API получит некорректную семантику, хотя формально запрос отклонён". Починка — завести отдельный ErrInvalidFulfillmentType → 400, и заодно синхронизировать с enum в OpenAPI-схеме, где это поле и так описано как закрытое множество значений.

Второй похожий случай в том же проекте — отрицательные price_cents/stock при создании/обновлении позиции меню изначально не проверялись в коде вообще, полагаясь на Postgres CHECK-constraint. Формально работало (заказ с отрицательной ценой действительно отклонялся), но клиент видел 500 internal_error вместо 400 — ошибка constraint'а БД не переводилась в доменную ошибку, а просто падала как generic DB error.

Вывод, который стоит проговорить на собесе явно: "работает" и "работает с правильным контрактом ошибок" — разные вещи. Граница ответственности за валидацию должна быть в коде (usecase-слой), а не переложена на БД, потому что только код может вернуть осмысленный код ошибки клиенту.

"В юнит-тестах in-memory хранилище — какие недостатки, почему не поднял Postgres?"

Это вопрос с ловушкой в самой формулировке — правильный ответ не "поднял бы, но не успел", а "не поднял намеренно, и вот почему это правильное решение, а не компромисс из лени":

Unit-тесты (моки repository-интерфейсов, in-memory fake) проверяют бизнес-правила изолированно — например, что заказ с двумя одинаковыми item_id в теле схлопывается в одну позицию с суммой количества до похода в БД (реальный баг, пойманный на ревью: список ID для проверки наличия строился с дублями, а количество считалось в map без дублей — в результате репозиторий находил меньше позиций, чем ожидалось, и клиент получал вводящий в заблуждение 404 вместо здравой обработки). Такой тест выполняется за микросекунды, не требует Docker, можно гонять сотни раз в CI без сетевых издержек.

Чего in-memory fake никогда не проверит: - Настоящую SQL-семантику: WHERE stock >= $qty — это не просто условие в Go-коде, это часть конкретного UPDATE-запроса, и то, что он атомарен именно как один SQL statement, а не read-then-write из двух шагов, проверяется только реальным Postgres под конкурентной нагрузкой. - Блокировки: SELECT ... FOR UPDATE перед сменой статуса заказа — фейковый repository физически не может воспроизвести поведение блокировки строки под параллельными транзакциями, потому что там просто нет транзакций. - Реальные гонки между процессами: два параллельных запроса на отмену одного заказа, два конкурентных заказа на один и тот же товар. - Транзакционность нескольких операций вместе — что вставка заказа, позиций заказа и события в outbox либо все применяются, либо ни одна (см. ниже про transactional outbox).

Поэтому у проекта два разных уровня тестов с разными целями, а не один "побольше покрытие":

  • табличные unit-тесты usecase-слоя — моки, микросекунды, проверяют бизнес-правила;
  • отдельный e2e-сьют через testcontainers-go, который поднимает весь docker-compose.yml целиком (два реальных Postgres, реальная Kafka, оба сервиса) и гоняет сквозные сценарии по-настоящему: create → push через Kafka → establishment видит заказ → серия /advance → completed, отдельно недостаточный остаток (409, остаток не тронут), отдельно отмена с восстановлением остатка и идемпотентной повторной отменой, отдельно — сценарий доставки в dead-letter топик при невалидном переходе статуса.

Это и есть содержательный ответ на "почему не поднял Postgres в юнит-тестах": Postgres поднят, просто не в юнит-тестах, а там, где он действительно нужен — в отдельном, более медленном (~90 секунд) слое тестирования с другой целью.

Осознанно НЕ протестировано моками "ради покрытия": сам repository-слой. Мокать pgx ради теста OrderRepository.Create даёт ложное чувство защищённости — тест пройдёт, даже если реальный SQL синтаксически неверен, потому что мок никогда не исполняет настоящий запрос. Это тоже стоит уметь сформулировать вслух: "не тестируем X мокaми" — не пробел, а решение, потому что моки для X ничего не гарантируют сверх факта компиляции.

"Что будет при 100 RPS, выдержит ли нагрузку?"

Самый содержательный способ ответить на этот вопрос — не общими словами про "масштабируемость", а конкретным разбором, что именно в системе является узким местом при конкурентной нагрузке — и один нюанс проекта прямо об этом.

Списание остатка — это то место, где конкурентность реально бьёт по throughput. Первый вариант, который приходит в голову — пессимистичная блокировка: SELECT ... FOR UPDATE строки товара, потом UPDATE. Работает, но держит блокировку на всё время транзакции заказа, и при заказе с несколькими позициями, взятыми в разном порядке разными параллельными транзакциями — прямой путь к дедлокам под нагрузкой.

Решение, которое я использовал: атомарный UPDATE items SET stock = stock - $qty WHERE id = $id AND stock >= $qty + проверка RowsAffected() > 0. Это одна операция на уровне СУБД, не требует отдельного SELECT перед ней, критическая секция короче, и естественным образом не даёт остатку уйти в минус — сама WHERE-часть является проверкой.

Дополнительно — позиции заказа перед обработкой сортируются по item_id, чтобы параллельные заказы с пересекающимся набором товаров блокировали строки в одинаковом порядке, а не в порядке, в котором их прислал клиент (полный разбор с рабочим кодом — в «Конкурентные остатки: блокировки, дедлоки, гонки»).

Кэш — не абстрактная демонстрация, а прямой ответ на "выдержит ли 100 RPS". В задаче 3 с самого собеса (улучшение ручки погоды через кэш) интервьюер прицельно проверяет, понимает ли кандидат разницу между "добавил кэш" и "добавил кэш, который переживёт всплеск параллельных промахов". Наивный TTL-кэш под мьютексом на 50 параллельных запросах в момент протухания TTL честно ходит в апстрим 50 раз — thundering herd, кэш почти бесполезен именно в пиковый момент.

Правильный вопрос-встречный на "выдержит ли 100 RPS" — не "да", а "какая операция за этим RPS стоит". Если это чтение меню — не проблема, PostgreSQL с индексами легко держит такой поток чтений.

Если это запись (создание заказа с конкурентным списанием остатка одного и того же товара) — именно там нужен разбор конкретного механизма (см. выше), потому что там узкое место не "медленный код", а сериализация доступа к одной строке БД.

Честный ответ про транзакционность vs пропускную способность. CreateOrder целиком выполняется в одной транзакции: списание остатков по всем позициям, вставка заказа, вставка позиций, запись в outbox — всё или ничего. Это правильно с точки зрения консистентности (частично созданного заказа быть не должно), но означает, что при большом заказе (много позиций) транзакция держится дольше — прямой trade-off между корректностью и латентностью одного запроса.

На интервью это стоит проговорить как осознанный выбор, а не как то, о чём не подумал: для MVP с реалистичным размером заказа (единицы-десятки позиций) цена приемлема, для системы, ожидающей заказы на сотни позиций, потребовалось бы отдельное решение (например, батчинг списаний одним запросом вместо цикла).

"Зачем тебе Kafka, а не просто HTTP callback?"

Тут стоит быть готовым к честному, не приукрашенному ответу — потому что это ровно тот случай, где я сам сначала выбрал другое решение (HTTP push + callback), реализовал его, а потом переделал на Kafka по отдельному запросу на архитектурный пересмотр. Это хороший пример для собеса именно потому, что показывает: можно поменять архитектурное решение постфактум, если новые требования/пожелания это оправдывают, и не стесняться объяснить, почему первая версия тоже была разумной.

Первая версия (HTTP push + callback). Основной сервис при подтверждении заказа делает POST в сервис заведения, тот отвечает синхронным ACK, дальше заведение асинхронно меняет статус и коллбэком (PATCH) уведомляет основной сервис. Плюс — нет задержки поллинга, реалистичная модель. Минус — основной сервис должен знать URL заведения и обрабатывать его недоступность: ретраи с backoff, и что делать, если заведение "зависло" на неопределённое время (в первой версии — отдельный статус establishment_unreachable, который потом сам же убрал, потому что статус без выходного перехода в state machine хуже, чем его отсутствие).

Вторая версия (Kafka, по явному запросу на пересмотр). Два топика по направлениям (orders.new, orders.status_updated), ключ сообщения — order_id, чтобы Kafka гарантировала порядок в пределах одного заказа (все сообщения одного заказа лежат в одной партиции). Честный ответ на "почему Kafka, а не RabbitMQ": для реального объёма сообщений в MVP (единицы-десятки заказов) RabbitMQ был бы пропорциональнее по сложности — выбор Kafka обоснован скорее демонстрацией конкретного инструмента, чем нагрузочной необходимостью именно этого объёма. Проговорить это вслух самому — сильнее, чем ждать, пока интервьюер это заметит и спросит.

Что реально решает переход на брокер, а не просто "модно": - Убирает целый класс проблем с надёжностью push — раньше нужно было отдельно продумывать реконсиляцию (поллер, который синхронизирует зависшие заказы), теперь durable-очередь + at-least-once доставка + consumer group offset дают то же самое "из коробки" брокера, без отдельного механизма поверх. - Требует нового вида надёжности взамен: transactional outbox (см. ниже) — потому что просто "закоммитить заказ в БД, потом опубликовать в Kafka" двумя raздельными шагами создаёт то же окно потери сообщения, что и раньше, просто в другом месте.

"Почему не в БД лежит очередь на публикацию?" — transactional outbox

Между COMMIT транзакции создания заказа и publish в Kafka процесс может упасть — заказ уже в БД, сообщение не отправлено, establishment-service никогда о нём не узнает. Решение — таблица outbox_events (topic, key, payload, published_at), запись в которую делается той же транзакцией, что и сам заказ:

// внутри tx.WithinTx вместе с созданием заказа
outboxWriter.Enqueue(ctx, domain.TopicOrdersNew, orderID.String(), payload)

Отдельный фоновый воркер (Relay, тикер) вычитывает неопубликованные строки, публикует, и только после успешного publish помечает published_at. Если публикация упала — строка остаётся неопубликованной, следующий тик повторит попытку. Повторная публикация уже отправленного сообщения безопасна, потому что consumer на другой стороне идемпотентен (см. ниже).

Ключевой вопрос, который здесь стоит уметь предвосхитить: "а что, если сам relay упадёт после publish, но до того, как пометит published_at?" — ответ: сообщение опубликуется в Kafka ещё раз при следующем тике. Именно поэтому важно, что consumer идемпотентен — at-least-once на уровне outbox + at-least-once на уровне Kafka вместе дают "сообщение доставится хотя бы раз", а не "ровно раз", и вся система спроектирована так, чтобы повторная доставка была безопасна, а не запрещена.

Отдельная деталь, которую я специально зафиксировал как осознанную асимметрию, а не недосмотр: outbox есть только у kitchen-service (сторона, создающая заказ — потеря сообщения здесь потеряла бы реальный заказ). Establishment-service публикует статус синхронно из /advance-хендлера без outbox — обосновано тем, что это ручное демо-действие, инициированное человеком, и повторный вызов /advance с тем же действием естественно повторяет попытку публикации, если она не удалась в первый раз (детерминированный idempotency_key = "{order_id}:{status}" гарантирует, что повтор не создаст дубль на стороне kitchen).

"Как обрабатываешь сообщение, которое никогда не станет валидным?" — DLQ и poison message

Consumer в kitchen-service коммитит offset после успешной обработки (at-least-once, не at-most-once) — если процесс падает после применения перехода, но до commit, сообщение придёт повторно; уже существующая идемпотентность (SELECT ... FOR UPDATE + no-op при повторе того же статуса) делает это безопасным без доп. кода.

Настоящий вопрос — что делать с сообщением, которое в принципе не станет валидным (например, sent_to_establishment → completed, пропуская все промежуточные статусы — недопустимый переход по конечному автомату). Если просто ретраить бесконечно — сообщение никогда не пройдёт, и партиция блокируется навсегда для всех последующих заказов (Kafka не даёт "пропустить одно сообщение и обработать следующее" без явного действия). Решение — различать два класса ошибок:

  • Доменная ошибка (ErrInvalidTransition — переход не разрешён и не совпадает с текущим статусом) — обрабатывать её повторно бессмысленно, она никогда не станет валидной сама по себе. Коммитим offset сразу и публикуем оригинальное сообщение + причину в отдельный orders.status_updated.dlq топик.
  • Транзиентная ошибка (БД временно недоступна) — до 5 попыток с экспоненциальным backoff в рамках обработки этого же сообщения, не через commit/re-fetch — потому что Kafka-оффсет это watermark, а не индивидуальный ack на сообщение: закоммитить N+1 и надеяться, что N "ещё в очереди", нельзя — коммит N+1 неявно прощает N навсегда. После исчерпания попыток транзиентная ошибка тоже уходит в DLQ, а не блокирует партицию бесконечно.

Это прямой, честный ответ на "как обработаешь ошибку, можно ли улучшить": можно было бы просто ретраить бесконечно и молча блокировать партицию — я явно выбрал не так, потому что для одного "плохого" заказа блокировать обработку статусов всех остальных заказов — цена выше, чем ручной разбор редкого случая из DLQ.

Что ещё стоит уметь объяснить с ходу

  • Почему деньги — int в центах, а не float. Плавающая точка не гарантирует точное представление десятичных дробей — 0.1 + 0.2 != 0.3 в IEEE 754, и это реальный источник багов округления в финансовом коде. Целые минимальные единицы (центы) исключают этот класс проблем полностью.
  • Почему пользовательской аутентификации нет. ТЗ явно запрещает её реализовывать — пользователь считается уже прошедшим сквозную авторизацию платформы выше уровня этого сервиса. Стоит явно отделить это от того, что API заведений всё-таки защищено статическим API-ключом — это два разных требования ТЗ (открытый клиентский API vs закрытый список заведений), не непоследовательность.
  • Почему soft delete, а не настоящий DELETE. order_items.item_id — внешний ключ на items. Настоящий SQL DELETE строки, на которую уже есть исторические заказы, либо заблокирован FK, либо требует CASCADE/SET NULL, что ломает историю уже оформленных заказов. UPDATE is_active = false решает то же самое без потери целостности.
  • Почему пагинация limit/offset, а не курсорная. Простая offset-пагинация закрывает потребность MVP (список заказов пользователя, список позиций меню — объёмы, где offset на больших страницах ещё не деградирует заметно). Честно называть это ограничением, а не идеальным решением, если объём данных вырастет на порядки.
  • Как объяснить решение, которое сам же потом изменил. Работа с журналом решений (DECISIONS.md) в этом проекте — не просто дневник, а прямая тренировка того самого навыка, который проверяет собес: для каждого нетривиального решения зафиксированы варианты, плюсы/минусы каждого, что выбрано и почему — включая случаи, где решение пересматривалось (HTTP push → Kafka, 200 → 502 при сбое publish). На интервью это переносится напрямую: если решение изменилось по ходу работы — это сильная сторона рассказа ("я сначала выбрал X, вот почему, потом понял/попросили пересмотреть, переделал на Y, вот что изменилось и почему") — не слабость, которую нужно скрывать.

Итог: что реально топит кандидата на этой части собеса

Три наблюдаемых паттерна из разобранного случая с собеса (кандидат не прошёл), которые стоит вслух проверить на себе заранее:

  1. "Почему так" без готового ответа. Если на любое решение в собственном коде единственный ответ — "ну, показалось нормальным", это будет заметно. Решение должно уметь называть отклонённую альтернативу и конкретный минус, который её отклонил — не абстрактно "так лучше", а "вариант A давал X, но платил Y, я выбрал вариант B, потому что для этой задачи Y дороже X".
  2. Непокрытый edge case, который сам не нашёл заранее. У провалившего собес кандидата ручка теряла бы 10% пользователей на непокрытом крае — по словам интервьюера, не из-за незнания, а из-за того, что кандидат сам не прогнал в голове полный набор входов до собеса. Прямая защита — то же ревью, что я делал для этого проекта: явно искать "а что если N пустых, N огромное, два одинаковых ID в одном запросе, два параллельных запроса на один ресурс" — и на каждое иметь готовый ответ, даже "осознанно не покрыто, вот почему это не критично для MVP".
  3. Путаница "работает" и "работает правильно". Оба реальных бага, разобранных выше (fulfillment_type → неверный HTTP-код, отрицательная цена → 500 вместо 400) — код в обоих случаях технически "работал" (отклонял некорректный ввод), просто неправильным способом. Собес это ловит вопросом "а что вернётся клиенту" — не "а упадёт ли", а именно "что увидит вызывающая сторона, и правильно ли это с точки зрения контракта API".

Смотри также — отдельные глубокие разборы

Каждая тема выше по объёму — на абзац-два. Ниже — те же темы, но развёрнутые в отдельные страницы с полным кодом, разобранными багами и готовыми формулировками для конкретных вопросов интервьюера:

Самопроверка 0 / 5
Могу объяснить, зачем нужен transactional outbox и что ломается без него
Знаю, почему атомарный UPDATE...WHERE stock>=qty лучше, чем SELECT FOR UPDATE, для списания остатка
Умею честно ответить, почему in-memory хранилище в unit-тестах не заменяет интеграционные тесты
Могу посчитать вслух, что реально ограничивает пропускную способность моего сервиса на 100 RPS
Понимаю, зачем интерфейсы репозиториев объявлены в usecase-слое, а не в repository
Как усвоено?